Skip to content

feat(news): unified story-identity service — one similarity definition for clustering, dedup, corroboration (#4919)#4924

Merged
koala73 merged 4 commits into
mainfrom
feat/story-identity-4919
Jul 6, 2026
Merged

feat(news): unified story-identity service — one similarity definition for clustering, dedup, corroboration (#4919)#4924
koala73 merged 4 commits into
mainfrom
feat/story-identity-4919

Conversation

@koala73

@koala73 koala73 commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Closes #4919 (Bet 1 of the 2026-07-05 strategic news-pipeline review). Do not auto-merge — held for review.

What this changes

Story identity had three inconsistent answers, and the most load-bearing one was exact-match:

Consumer Before After
Insights clustering (scripts/_clustering.mjs) title-token Jaccard ≥ 0.5, own tokenizer/stopwords shared vectors + STORY_SIMILARITY_THRESHOLD
Headline dedup (news/v1/dedup.mjs) word-overlap > 0.6 of smaller set shared vectors, same threshold
Story tracking / corroboration (list-feed-digest.ts:1246) exact sha256(normalizeTitle) — any wording edit forked the story assignStoryIdentity: cluster the batch, share one canonical hash + cluster-wide source count

The corroboration change is the payoff: corroborationCount feeds computeImportanceScore (weight 0.15) and the BREAKING/DEVELOPING phase tracker, and under exact hashing it only counted verbatim wire syndication. Now three outlets wording the same event differently count as 3-source corroboration (regression-tested).

The similarity method (and its honest limits)

shared/story-identity.js (+ scripts/shared/ byte-mirror, enforced by the mirror test): dual-view feature-hashed lexical vectors — a uniform-weight view and an entity/number-boosted view (capitalized-in-raw tokens ×3, numeric tokens ×2); similarity = min of the two cosines. Each view catches what the other is blind to: the uniform view separates action swaps ("seizes tanker" vs "threatens to close") that entity weighting washes out; the boosted view separates one-entity swaps ("Turkey hikes rates to 50%" vs "Argentina hikes rates to 50%") that score ~0.82 under flat weights. Features: word tokens, word bigrams (order/direction — separates "Israel strikes Hezbollah" from "Hezbollah strikes Israel"), char 4-grams (morphology: iran/iranian), char bigrams for unsegmented scripts (CJK tested).

Threshold 0.615 tuned on a labeled pair set committed as test ground truth: 10 edit-variant positives (min 0.634) vs 9 same-topic-different-event negatives (max 0.595), with a margin trip-wire test that fails any retune that collapses the separation.

Documented limits: (1) two events differing by one unboosted token ("China exports fall" vs "China imports fall", "12th" vs "13th sanctions package") are not separable by any lexical similarity — the 96h window and entity-corroboration signals bound the damage; (2) cross-language paraphrase needs a semantic embedding — the setStoryVectorProvider() seam accepts one without touching any consumer.

Migration safety

  • Canonical cluster hash = sha256 of the lexicographically smallest normalized member title → singleton clusters keep their exact pre-change story:track keys (tested). Multi-wording clusters consolidate; when the smallest member ages out of feeds a new track is minted — the worst case equals today's behavior for every wording, the common case consolidates.
  • Redis schema unchanged; only WHICH id members adopt changes.

Verification

  • New tests/story-identity.test.mjs (20 tests): labeled-pair separation + margin trip-wire, CJK, all-caps, determinism, provider seam, assignStoryIdentity acceptance (corroboration-rises regression, singleton key stability, order independence, same-outlet-counts-once).
  • Green: clustering (16), clustering-cap, server-handlers (24), importance-score-parity (26), diplomacy-keywords-parity, scripts-shared-mirror (11, now includes story-identity.js).
  • typecheck + typecheck:api + biome + docs-stats --check clean on today's origin/main.

https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP

…n for clustering, dedup, and corroboration (#4919)

Story identity previously had three inconsistent answers: _clustering.mjs
(title-token Jaccard >= 0.5), news/v1/dedup.mjs (word-overlap > 0.6), and
list-feed-digest.ts story tracking (EXACT sha256 of the normalized title
— any wording edit forked the story, so corroboration only counted
verbatim wire syndication, deflating importanceScore's corroboration
signal and the BREAKING/DEVELOPING phase tracker).

New shared/story-identity.js (+ scripts/shared mirror, d.ts): dual-view
feature-hashed lexical vectors — a uniform-weight view and an
entity/number-boosted view; similarity = min of the two cosines, so a
pair merges only when both views agree. Each view catches the failure
mode the other is blind to (action swaps vs one-entity swaps). Threshold
0.615 tuned on a labeled pair set committed as the test-suite ground
truth (min positive 0.634, max negative 0.595); known limits (one-token
swaps, cross-language paraphrase) documented, with a
setStoryVectorProvider seam for a future semantic embedding upgrade.

All three call sites now delegate:
- _clustering.mjs drops its local Jaccard matcher (insights clustering)
- dedup.mjs reimplements deduplicateHeadlines on shared vectors and adds
  assignStoryIdentity (cluster batch -> canonical titleHash + cluster-wide
  corroboration count; canonical = hash of lexicographic-min normalized
  member title, so singleton clusters keep their exact pre-change keys)
- list-feed-digest.ts corroboration pass uses assignStoryIdentity;
  cluster members share one story:track identity and a real multi-wording
  source count

Closes #4919

🤖 Generated with Claude Code
Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 6, 2026 5:17am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces three inconsistent "same story?" definitions (Jaccard-0.5 in clustering, word-overlap-0.6 in dedup, exact-SHA256 in corroboration tracking) with a single module: a dual-view feature-hashed lexical vectorizer with cosine similarity at a threshold tuned on a labeled pair set. The payoff is that corroborationCount — which feeds importanceScore and the BREAKING/DEVELOPING phase tracker — now counts cross-wording corroboration instead of only verbatim wire syndication.

  • shared/story-identity.js (mirrored to scripts/shared/) provides storyVector, cosineSimilarity, clusterTexts, and assignStoryIdentity; the dual-view design (uniform + entity-boosted) guards against both action-swap and entity-swap false positives, and the setStoryVectorProvider seam allows a future semantic embedding drop-in.
  • dedup.mjs gains assignStoryIdentity: greedy-cluster the batch, pick the lex-smallest normalized title as the canonical hash, and assign every cluster member the same titleHash + cluster-wide source count; singleton clusters hash identically to the pre-change exact-SHA256 path.
  • list-feed-digest.ts replaces the exact-hash corroboration map with a single assignStoryIdentity call; object-reference keying into the returned Map is correct, and a defensive fallback covers the (by-construction impossible) missing-assignment case.

Confidence Score: 4/5

The change is safe to merge; the corroboration and hash-key regression tests are thorough and the singleton-key stability guarantee is verified end-to-end.

The core vectorizer, assignStoryIdentity, and the list-feed-digest integration all look correct and are well-tested. Two spots warrant a second look: clusterItems in _clustering.mjs re-implements the greedy clustering loop that clusterTexts now exports from the same mirrored module — an algorithm change in one place won't automatically propagate to the other. And assignStoryIdentity passes raw (un-normalized) titles to clusterTexts while using normalizeTitle only for the canonical hash, which creates a slight asymmetry that could suppress clustering for very short headlines whose source-attribution suffix is proportionally significant.

scripts/_clustering.mjs (duplicated clustering loop) and server/worldmonitor/news/v1/dedup.mjs (raw-title input to clusterTexts)

Important Files Changed

Filename Overview
shared/story-identity.js New core module: dual-view feature-hashed lexical vectorizer with deterministic greedy clustering; well-documented, threshold tuned on labeled pair set, null-safe throughout.
scripts/shared/story-identity.js Byte-for-byte mirror of shared/story-identity.js for the Railway seed bundle (rootDirectory=scripts); enforced by mirror test.
scripts/_clustering.mjs Removes local Jaccard + stopword tokenizer, delegates similarity to shared module; however the greedy clustering loop is still duplicated rather than delegating to the now-exported clusterTexts.
server/worldmonitor/news/v1/dedup.mjs Adds assignStoryIdentity (fuzzy clustering → canonical hash + corroboration count); raw titles passed to clusterTexts while normalizeTitle is only used for hashing creates a minor asymmetry on short headlines with source-attribution suffixes.
server/worldmonitor/news/v1/list-feed-digest.ts Replaces exact-hash corroboration map with assignStoryIdentity; object-reference keying is correct, defensive fallback is sound, and the story:track key stability guarantee holds for singleton clusters.
server/worldmonitor/news/v1/dedup.d.mts New type declaration file for dedup.mjs; resolves the previous @ts-expect-error on the _shared.ts re-export.
tests/story-identity.test.mjs 20 tests covering labeled-pair separation with margin trip-wire, CJK, all-caps, determinism, provider seam, assignStoryIdentity corroboration regression, singleton key stability, and order independence.
shared/story-identity.d.ts Clean type declarations for the new module; all exports covered including the StoryVector interface and the optional-threshold overload on isSameStory.
tests/scripts-shared-mirror.test.mjs Adds story-identity.js to the mirror enforcement list; ensures both copies stay byte-for-byte identical.
tests/server-handlers.test.mjs Updates dedup comment to reflect new similarity method; assertion logic unchanged.
server/worldmonitor/news/v1/_shared.ts Removes @ts-expect-error now that dedup.d.mts provides types; clean one-line change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant LFD as list-feed-digest.ts
    participant DEDUP as dedup.mjs
    participant SI as shared/story-identity.js
    participant REDIS as Redis (story:track)

    LFD->>DEDUP: assignStoryIdentity(allItems, normalizeTitle, sha256Hex)
    DEDUP->>SI: clusterTexts(rawTitles)
    SI-->>DEDUP: clusters (index[][])
    loop per cluster
        DEDUP->>DEDUP: "canonical = min(normalizeTitle(titles)).sort()[0]"
        DEDUP->>DEDUP: "titleHash = sha256Hex(canonical)"
        DEDUP->>DEDUP: "corroborationCount = uniqueSources.size"
    end
    DEDUP-->>LFD: "Map<item, {titleHash, corroborationCount}>"
    LFD->>LFD: "item.titleHash = identity.titleHash"
    LFD->>LFD: "item.corroborationCount = identity.corroborationCount"
    LFD->>REDIS: "HINCRBY story:track:{titleHash} mentionCount 1"
    LFD->>LFD: computeImportanceScore(corroborationCount x 0.15)

    Note over SI: Also used by _clustering.mjs (scripts/shared mirror) and deduplicateHeadlines
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant LFD as list-feed-digest.ts
    participant DEDUP as dedup.mjs
    participant SI as shared/story-identity.js
    participant REDIS as Redis (story:track)

    LFD->>DEDUP: assignStoryIdentity(allItems, normalizeTitle, sha256Hex)
    DEDUP->>SI: clusterTexts(rawTitles)
    SI-->>DEDUP: clusters (index[][])
    loop per cluster
        DEDUP->>DEDUP: "canonical = min(normalizeTitle(titles)).sort()[0]"
        DEDUP->>DEDUP: "titleHash = sha256Hex(canonical)"
        DEDUP->>DEDUP: "corroborationCount = uniqueSources.size"
    end
    DEDUP-->>LFD: "Map<item, {titleHash, corroborationCount}>"
    LFD->>LFD: "item.titleHash = identity.titleHash"
    LFD->>LFD: "item.corroborationCount = identity.corroborationCount"
    LFD->>REDIS: "HINCRBY story:track:{titleHash} mentionCount 1"
    LFD->>LFD: computeImportanceScore(corroborationCount x 0.15)

    Note over SI: Also used by _clustering.mjs (scripts/shared mirror) and deduplicateHeadlines
Loading

Comments Outside Diff (2)

  1. scripts/_clustering.mjs, line 125-168 (link)

    P2 Clustering loop duplicated from clusterTexts

    clusterItems lines 128–166 re-implement the same greedy inverted-index clustering algorithm that now lives in clusterTexts in the mirrored scripts/shared/story-identity.js. The only difference is that clusterItems maps indices → items at line 167 before handing off to the post-processing block (lines 170–209). Since clusterTexts returns number[][], the initial greedy phase could delegate to it:

    const textClusters = clusterTexts(items.map(item => item.title || ''));
    const groups = textClusters.map(indices => indices.map(i => items[i]));

    The rest of the function (tier-sort, source-dedup, lastUpdated, importance aggregation) is unaffected. As-is, an algorithm fix in clusterTexts (e.g. chaining, edge-case guard) would silently not apply to clusterItems, which is exactly the maintenance risk the PR was trying to eliminate.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. server/worldmonitor/news/v1/dedup.mjs, line 537 (link)

    P2 Raw titles (not normalizeTitle-d) fed to clustering

    clusterTexts receives raw item titles, while normalizeTitle is only applied when computing the canonical hash (line 542). Source-attribution suffixes like "- Reuters" or "— live updates" are still present in the text that storyVector sees, so "Reuters" appears as a capitalized token and receives BOOST_ENTITY × 3 in the boosted view. In most cases this is harmless (the shared content vastly dominates), but for very short headlines (≤ 5 content words) where the suffix is proportionally large, the boosted-view cosine can be noticeably suppressed compared to a pair with identical source attributions — potentially missing a cluster that normalizeTitle would have resolved. Passing items.map(item => normalizeTitle(item.title) || '') to clusterTexts instead would make the clustering input consistent with the hash input.

Reviews (1): Last reviewed commit: "feat(news): unified story-identity servi..." | Re-trigger Greptile

…union-find clustering, suffix-blind vectors, per-hash tracking writes (#4924 review round)

Applies the multi-agent + cross-model review findings on PR #4924:

- canonical id anchors on the EARLIEST-published member (reliability P1,
  conf 100, corroborated by the story:track 3-incident history): a
  later-joining wording can no longer steal the identity mid-lifecycle
  and reset mentionCount/firstSeen/phase
- clusterTexts uses union-find connected components instead of greedy
  first-seed assignment (cross-model finding): cluster membership — and
  therefore the persistent identity — is now input-order independent
- source-attribution suffixes are stripped before vectorization
  (cross-model P1): Google-News wrapper titles carry "- Publisher" on
  every item, and the entity-boosted publisher token pulled DISTINCT
  same-wrapper stories toward a false merge
- containment rescue (>=90% token containment, min 4 tokens) restores
  the old dedup metric's guarantee for severely truncated headlines
  (testing P1, proven regression)
- writeStoryTracking runs mutable per-story writes (mentionCount
  HINCRBY, representative HSET) once per unique hash per cycle with a
  deterministic representative — per-member writes would inflate
  mentionCount by N per cycle and skip the DEVELOPING phase
  (reliability P2 + correctness, promoted on agreement)
- emoji/punctuation-only titles get per-item sentinel identities
  instead of pooling into one sha256("") phantom track (adversarial +
  testing, conf 100)
- hot-token candidate buckets capped at 250 (adversarial perf cascade);
  identity input clamped to 300 chars (unbounded-title n-gram bomb)
- clusterItems delegates to clusterTexts (maintainability P1) — the
  clustering ALGORITHM is now shared too, not just the similarity
- corroboration_count proto doc describes the edit-tolerant semantics;
  OpenAPI specs regenerated via make generate
- coverage-miss fallback logs instead of degrading silently;
  _clustering.mjs added to the mirror-import guard list

New regression tests: canonical stability across sequential builds,
order-permutation invariance, truncation + suffix labeled pairs,
degenerate-title sentinels, once-per-hash tracking (source-textual),
integration wiring assertions. 30/30 in story-identity suite; full
affected battery + both typechecks green.

Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
@mintlify

mintlify Bot commented Jul 5, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Jul 5, 2026, 7:27 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@koala73

koala73 commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Multi-agent code review — applied at b906d90

Nine reviewer personas (correctness, testing, maintainability, project-standards, performance, reliability, adversarial, agent-native, learnings) plus an independent cross-model adversarial pass via Codex CLI. All actionable findings applied in the fix(review) commit; verdict below.

Applied (10 findings)

# Sev Finding Reviewers Fix
1 P1 Canonical id churns/steals mid-lifecycle (lexmin of current batch) — resets mentionCount/firstSeen/phase on live stories reliability(100) + adversarial(100) + learnings + correctness + codex publishedAt-anchored canonical; sequential-builds regression test
2 P1 Greedy first-seed clustering is input-order dependent -> identity jitter across runs codex(100) + adversarial residual union-find connected components; permutation test
3 P1 Publisher suffixes enter vectors ("- Reuters" entity-boosted x3) -> distinct same-wrapper stories pull together codex(100) stripAttributionSuffix before vectorization; suffix labeled pairs
4 P1 Severe truncation regression vs old containment metric testing(100) containment rescue (>=0.9, min 4 tokens); truncation pairs
5 P2 mentionCount +N per cycle for N-member clusters -> skips DEVELOPING reliability(75) + correctness + codex once-per-unique-hash HINCRBY, deterministic representative for HSET
6 P2 Emoji/punct-only titles pool into one sha256("") phantom track adversarial(100) + testing(100) per-item sentinel identities
7 P2 Hot-token O(N^2) cascade + unbounded title n-gram bomb in 25s budget adversarial(75) + codex(75) 250-cap candidate buckets; 300-char identity clamp
8 P1 clusterItems re-implements the clustering loop maintainability(100) delegates to clusterTexts
9 P2 corroboration_count proto doc still describes exact-title semantics agent-native proto comment + make generate (3 specs)
10 P3 Silent unreachable fallback; missing mirror-guard entry reliability(75) + standards warn log; guard list entry

Pushed back (1)

Residual risks (documented, tracked in follow-up)

  • A hostile feed can still backdate publishedAt within the 96h window to claim a cluster's canonical, and rotate titles to churn it — needs cross-cycle adopt-existing-track hardening (follow-up issue).
  • One-token-swap distinct events ("exports"/"imports", "12th"/"13th") remain above threshold — inherent lexical limit, bounded by the 96h window; revisit with a semantic provider.
  • Unkeyed FNV-1a feature hashing is offline-searchable for collisions (no per-deploy salt) — theoretical, noted in follow-up.

Verification

30/30 story-identity suite (incl. 6 new regression classes), full affected battery (110 tests), both typechecks, biome, mirror parity, make generate clean.

Verdict: Ready for human review — held for manual merge as requested (no auto-merge).

… hot-bucket caps (#4924 external review)

251 verbatim syndications previously made every shared token bucket
exceed MAX_CANDIDATE_BUCKET, so no pairs were scored and the day's
most-corroborated story degraded to singletons. Identical normalized
texts now union unconditionally before the candidate scan.

Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
… external review)

P1 — non-canonical variants lost cross-cycle continuity, WORSE than the
old exact hashing for that sub-case: cycle 1 (A+B, A canonical) wrote
only A's track; a cycle-2 B-only batch hashed to B and reset the story
to BREAKING/mentionCount=1. Now every cycle persists
memberHash -> canonicalHash alias rows (story:alias:v1, story-track
TTL) and the digest adopts the live canonical most members point at
(deterministic: most-common target, ties lexicographic) before
assigning hashes — one batched GET pipeline; failures degrade to
batch-derived canonicals. Two-cycle A+B -> B-only regression at the
pure level plus wiring pins.

P2 — EXPIRE for story:sources/story:peak was queued in the
once-per-hash PRE-block, before the per-member SADD/ZADD that CREATE
those keys: EXPIRE on a missing key is a no-op, so every brand-new
story leaked two persistent keys. The EXPIREs now ride adjacent to the
creating writes (idempotent per member); ordering pinned by test.

P2 hot-bucket identical-title clustering was already fixed this round
(exact-duplicate pre-union, ca80a2c). Pre-existing read/write prefix
parity filed as #4936.

story-identity 34/34, server-handlers 24, mirror 12, persistence 17,
typecheck:api clean.

Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
@koala73

koala73 commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

External review adjudication — fixed at 82a9409

# Verdict Fix
1 (P1) Validated, and sharper than my earlier framing — I had classed cross-cycle canonical churn as "same as old behavior, tracked in #4925". That claim was wrong for the variant-only-reappears sub-case: the old exact hashing continued B's own track, the new scheme reset it. Genuine regression. Every cycle persists memberHash → canonicalHash alias rows (story:alias:v1, story-track TTL); the digest adopts the live canonical most members point at (deterministic: most-common target, ties break lexicographically) before assigning hashes. One batched GET pipeline; read failure degrades to batch-derived canonicals. Two-cycle A+B → B-only regression at the pure level (adoptExistingCanonical exported) plus wiring pins.
2 (P2) ValidatedEXPIRE on the missing sources/peak keys no-op'd, then SADD/ZADD created them persistent. Every brand-new story leaked two keys forever. The two EXPIREs now ride adjacent to the creating per-member writes (idempotent); ordering pinned by a source-structure test so a future reorder can't reopen the leak.
3 (P2) Validated — already fixed this round at ca80a2c35 (exact-duplicate pre-union before the candidate scan, 251-identical-title regression, mirrored to scripts/shared/). Near-identical (non-exact) variants sharing only hot tokens still skip scoring by design — the CPU guard stands; the mass-syndication case the finding centers on is covered.
Pre-existing (read/write prefix parity) Confirmed real, preview-envs-only (production prefix is empty). Filed as #4936 per the repo's untracked-problems rule, not fixed here.

Batteries: story-identity 34/34, server-handlers 24, mirror 12, track-persistence 17; typecheck:api clean. Cascade-rebased through #4927#4928#4929. Still held for manual merge.

@koala73 koala73 merged commit 81b6ad2 into main Jul 6, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

epic(news): unified story-identity service — one embedding-based clustering layer for digest, insights, corroboration, and phase tracking (Bet 1)

1 participant